<?php
/**
 * The following is a description of the communication process between
 * the client and the server. This description is brief and only describes
 * the key ideas.
 *
 * Client
 *
 * The client initiates and maintains a TCP connection to the server to realize
 * data communication. Then, whenever the browser connects to the client
 * requesting data, the client will give the connection an ID, called
 * "client ID".
 *
 * Read data whenever the stream resource associated with the client ID can be
 * read. Then put the client ID, a control flag (indicating whether this data 
 * is the first data sent after a new client connects), the size of the data
 * read, and the data together and package them into a data block. Finally,
 * send the data block to the server through the TCP connection.
 *
 * The server sends encrypted data to the client. After the client decrypts,
 * the client ID and the associated plaintext are obtained. The client only
 * needs to send the plaintext to the corresponding browser connection.
 *
 * Server
 *
 * Read the client's packet and decrypt it. If the control flag indicates that
 * the packet is the first successful connection of the client ID, the server
 * needs to parse the header of the packet and establish a new HTTP connection
 * (to the remote). Otherwise, find the HTTP connection corresponding to the
 * client ID and forward the data sent from the client ID to the remote.
 *
 * The server reads the remote returned data package, finds the corresponding
 * client ID of the data package, and sends it to the client.
 */

namespace Cephalopod\SSL;

use Exception;

/**
 * Protocol for communication between client and server.
 *
 * NOTE: The encryption and decryption part is not the scope of this protocol.
 * OpenSSL or any other encryption and decryption methods can be used to
 * ensure communication security.
 */
class Protocol
{
    const CONTROL_FLAG_FIRST_DATA_NO = 1;
    const CONTROL_FLAG_FIRST_DATA_YES = 2;
    const CONTROL_FLAG_HEARTBEAT = 3;
    const HEARTBEAT_DELAY = 30;
    const HEARTBEAT_FAIL = 180;
    const DATA_START = "-";

    /**
     * Packaging client state and browser data.
     *
     * @param int $clientId
     * @param int $controlFlag CONTROL_FLAG_FIRST_DATA_NO or CONTROL_FLAG_FIRST_DATA_YES
     * @param string $plaintext
     * @param callback|null $encryptionFunction encryption function
     * @return string Packaged and encrypted data
     */
    public static function pack(int $clientId, int $controlFlag, string $plaintext, $encryptionFunction = null): string
    {
        $plaintextSize = strlen($plaintext);
        $s = self::DATA_START;
        $plaintextPackage = "$clientId $controlFlag $plaintextSize$s$plaintext";
        return is_null($encryptionFunction) ? $plaintextPackage : call_user_func($encryptionFunction, $plaintextPackage);
    }

    /**
     * Decrypt and unpack data.
     *
     * @param string $ciphertext
     * @param callback|null $decryptionFunction decryption function
     * @param string $residualData Contains last decrypted data that cannot be parsed according to the protocol.
     * @return array A array, contains the following keys:
     *               origin_datas      The original data, array, and each
     *                                 element contains the following keys:
     *                                     client_id      The client ID.
     *                                     control_flag
     *                                     plaintext      The decrypted data.
     *               residual_data     A string, contains decrypted data that
     *                                 cannot be parsed according to the
     *                                 protocol. It can be appended to the
     *                                 plaintext when the next call is unpacked.
     */
    public static function unpack(string $ciphertext, $decryptionFunction = null, string $residualData = ''): array
    {
        $s = self::DATA_START;
        $results = ['origin_datas' => [], 'residual_data' => ''];
        $plaintext = $residualData . (is_null($decryptionFunction) ? $ciphertext : call_user_func($decryptionFunction, $ciphertext));
        if (strlen($plaintext) < strlen("0 0 0$s")) {
            $results['residual_data'] = $plaintext;
            return $results;
        }
        do {
            if (false === ($dataStartPosition = strpos($plaintext, $s))) {
                $results['residual_data'] = $plaintext;
                return $results;
            }
            $head = substr($plaintext, 0, $dataStartPosition);
            $headInfo = explode(' ', $head);
            if (3 != count($headInfo)) {
                throw new Exception('Package error');
            }
            $clientId = intval($headInfo[0]);
            $controlFlag = intval($headInfo[1]);
            $plaintextSize = intval($headInfo[2]);
            if ($clientId <= 0 || $controlFlag <= 0 || $plaintextSize < 0) {
                throw new Exception('Package data out of range');
            }
            if ($dataStartPosition + 1 + $plaintextSize > strlen($plaintext)) {
                $results['residual_data'] = $plaintext;
                return $results;
            }
            $results['origin_datas'][] = [
                'client_id' => $clientId,
                'control_flag' => $controlFlag,
                'plaintext' => substr($plaintext, $dataStartPosition + 1, $plaintextSize),
            ];
            $plaintext = substr($plaintext, $dataStartPosition + 1 + $plaintextSize);
        } while(strlen($plaintext) > 0);
        return $results;
    }
}
